<?php

	namespace org\tekuna\core\configuration;

	use \DOMNode;
	use \DOMDocument;
	use \DOMElement;
	use \DOMAttr;
	use \Exception;

	use org\tekuna\base\Tekuna;
	
	use org\tekuna\core\context\Context;
	use org\tekuna\core\util\PharSafe;
	

	/**
	 * This implementation of the Configuration interface loads an object
	 * tree consisting of ConfigurationElements and ConfigurationAttributes from
	 * an XML file.
	 *
	 * Every element node of the XML file is converted to a corresponding ConfigurationElement
	 * of the result tree, every attribute is transformed into a ConfigurationAttribute. All text nodes
	 * are assigned to their corresponding ConfigurationElements.
	 *
	 * Namespaces of the XML file are converted into aspects. The core namespace http://tekuna.org/core/##TEKUNA_VERSION##/
	 * is mapped to the aspect 'core'. If there are no mapping elements defined, the namespace
	 * is used as aspect directly. There will be no warning reported into the logs in this case.
	 *
	 * If there are include-elements with a file attribute, the specified XML file is loaded into the resulting
	 * object tree as well. This file may use separate XML namespace definitions. Namespace to aspect mappings
	 * defined by aspect-mapper elements are applied globally on all included files. Circular includes are detected
	 * and ignored. A warning will be logged then into the log.
	 */
	class XmlConfiguration extends AbstractConfiguration {

		private
			$objLogger = NULL,
			$sRootFilename = '',
			$sRootFilenamePath = '',
			$sCoreNamespace = '',
			$sPluginNamespace = '';
			
		private static
			$arrLoadedFiles = array();


		/**
		 * Constructs a new XML configuration from the given XML file. The namespace of the core namespace can
		 * be provided. If omitted it defaults to http://namespaces.tekuna.org/core/##TEKUNA_VERSION##/.
		 *
		 * @param $sFilename the file name of the source XML file
		 * @param $sCoreNamespace optional: the name of the core namespace
		 * @param $sPluginNamespace optional: the name of the plugin namespace
		 */
		public function __construct($sFilename, $sCoreNamespace = NULL, $sPluginNamespace = NULL) {

			$this -> objLogger = Tekuna :: getLogger(__CLASS__);
			$this -> sRootFilename = $sFilename;
			$this -> sRootFilenamePath = dirname($sFilename);
			
			if ($sCoreNamespace === NULL) {
				
				$sCoreNamespace = 'http://namespaces.tekuna.org/core/'. Tekuna :: TEKUNA_VERSION .'/';
			}
			
			if ($sPluginNamespace === NULL) {
				
				$sPluginNamespace = 'http://namespaces.tekuna.org/plugin/'. Tekuna :: TEKUNA_VERSION .'/';
			}
			
			$this -> sCoreNamespace = $sCoreNamespace;
			$this -> sPluginNamespace = $sPluginNamespace;
		}

		/**
		 * This method triggers the actual loading of the configuration. The XML file (and all subsequent included ones)
		 * are parsed and transformed into an object tree consisting of ConfigurationElements and ConfigurationAttributes.
		 * This object tree is then available via getRootElement().
		 */
		public function loadConfiguration(Context $objContext) {

			// load the configuration from the given XML file
			$objRootElement = $this -> buildConfiguration($this -> sRootFilename);
			$this -> setRootElement($objRootElement);
		}

		
		/**
		 * This method builds the object tree from the given file. A ConfigurationException is thrown
		 * if the file does not exist or is not readable. If there are other exceptions during the load
		 * these exceptions will be boxed into a ConfigurationException (as previous exception).
		 *
		 * This method is called recursively if there are include elements found.
		 *
		 * @param string $sFilename the name of the XML file
		 * @throws ConfigurationException if the file does not exist or is not readable or something other goes wrong
		 * @return ConfigurationElement the root element of the object representation
		 */
		private function buildConfiguration($sFilename) {

			// check if file exists
			if (! file_exists($sFilename) || ! is_readable($sFilename)) {

				throw new ConfigurationException("The file '$sFilename' does not exist or is not readable.");
			}

			// check if file was processed already
			$sFilename = PharSafe :: realpath($sFilename);
			if (in_array($sFilename, self :: $arrLoadedFiles)) {

				$this -> objLogger -> warn("The file '$sFilename' was already included otherwhere. Ignoring this include.");
				return NULL;
			}

			$this -> objLogger -> info("Processing configuration file '$sFilename' ...");

			try {
				// load the DOM document
				$objDom = new DOMDocument();
				$objDom -> load($sFilename);

				// add loaded file to list of already loaded files to prevent cycles
				self :: $arrLoadedFiles[] = $sFilename;

				// transform first real DOM Node (no comment or PI) to ConfigurationNode tree
				foreach ($objDom -> childNodes as $objChildNode) {

					if ($objChildNode -> nodeType == XML_ELEMENT_NODE) {

						return $this -> transformDOMElementRecursively($objChildNode);
					}
				}

				throw new NoSuchElementException("No root element found in file '$sFilename'.");
			}
			catch (Exception $objException) {

				throw new ConfigurationException("Error while building configuration from file '$sFilename'.", -1, $objException);
			}
		}


		/**
		 * Returns true if the given namespace refers to an XMLSchema instance.
		 *
		 * @param string $sNamespace the namespace to check
		 * @return boolean true if the namespace refers to an XMLSchema instance
		 */
		private function isXMLSchemaInstanceNamespace($sNamespace) {

			if ($sNamespace === 'http://www.w3.org/2001/XMLSchema-instance') { return true; }
			if ($sNamespace === 'http://www.w3.org/1999/XMLSchema-instance') { return true; }

			return false;
		}


		/**
		 * Transforms the given DOMElement into a corresponding ConfigurationElement.
		 *
		 * @param DOMElement $objDOMElement the source DOM element
		 * @return ConfigurationElement the target configuration element object
		 */
		private function transformDOMElementRecursively(DOMElement $objDOMElement) {

			// build ConfigurationElement for this DOMElement
			$sAspect = $this -> extractAspect($objDOMElement);
			$sName = $this -> extractName($objDOMElement);
			$sValue = $this -> extractValue($objDOMElement);

			$objCN = new ConfigurationElement($sAspect, $sName, $sValue);


			// iterate all DOM attributes
			foreach ($objDOMElement -> attributes as $objDOMAttributeNode) {

				// filter out the schemaLocation attribute for validation
				if ($objDOMAttributeNode -> localName == 'schemaLocation'
				    && $this -> isXMLSchemaInstanceNamespace($objDOMAttributeNode -> namespaceURI)) {

					continue;
				}

				if ($objDOMAttributeNode -> nodeType == XML_ATTRIBUTE_NODE) {

					$sAspect = $this -> extractAspect($objDOMAttributeNode);
					$sName = $this -> extractName($objDOMAttributeNode);
					$sValue = $objDOMAttributeNode -> nodeValue;

					$objCA = new ConfigurationAttribute($sAspect, $sName, $sValue);
					$objCN -> addAttribute($objCA);
				}
			}


			// iterate all DOM childs
			foreach ($objDOMElement -> childNodes as $objDOMChildNode) {

				if ($objDOMChildNode -> nodeType == XML_ELEMENT_NODE) {

					$objChildElement = $this -> transformDOMElementRecursively($objDOMChildNode);

					// add the child element to the internal representation
					$objCN -> addChildElement($objChildElement);

					// check if there is an include element
					if ($objChildElement -> getAspect() == 'core'
					    && $objChildElement -> getName() == 'include'
					    && $this -> isChildElementOfRootElement($objDOMChildNode)) {

						// load included config
						$sFilename = $objChildElement -> getAttribute('core', 'file') -> getValue();
						
						// load include from phar if phar given
						if ($objChildElement -> hasAttribute('core', 'phar')) {
							
							$sPhar = $objChildElement -> getAttribute('core', 'phar') -> getValue();
							$sFilename = 'phar://'. $this -> sRootFilenamePath . DIRECTORY_SEPARATOR . $sPhar . DIRECTORY_SEPARATOR . $sFilename;
						}
						else {
						
							// load include from filesystem else
							$sFilename = $this -> sRootFilenamePath . DIRECTORY_SEPARATOR . $sFilename;
						}
						
						$objIncludedFileRootElement = $this -> buildConfiguration($sFilename);

						// if there was an include cycle this object may be null
						if ($objIncludedFileRootElement !== NULL) {

							// add all child elements of that config to this config
							foreach ($objIncludedFileRootElement -> getChildElements() as $objChildElement2) {

								$objCN -> addChildElement($objChildElement2);
							}

							// do something here with the attributes of the included root element?
							// ... currently not.
						}
					}
				}
			}

			return $objCN;
		}

		/**
		 * Extracts the aspect from a given DOMNode using the XML namespace. If the namespace is equal
		 * to the core namespace, 'core' is returned. If not, the XML namespace is returned directly.
		 *
		 * @param DOMNode $objDOMNode the source node
		 * @return string the extracted aspect (or the whole namespace)
		 */
		private function extractAspect(DOMNode $objDOMNode) {

			$sNamespace = $objDOMNode -> namespaceURI;

			// Due to the XML specification the attribute namespace defaults to the
			// namespace of the element. See:
			// http://www.w3.org/TR/2009/REC-xml-names-20091208/#defaulting
			if ($sNamespace == '' && $objDOMNode -> nodeType == XML_ATTRIBUTE_NODE) {

				$sNamespace = $objDOMNode -> parentNode -> namespaceURI;
			}

			// try to map given namespace to aspect.
			if ($sNamespace == $this -> sCoreNamespace) {

				$sAspect = 'core';
			}
			elseif ($sNamespace == $this -> sPluginNamespace) {

				$sAspect = 'plugin';
			}
			else {

				// if no mapping given take namespace directly as aspect
				// may be re-mapped by an core:aspect-mapping element
				$sAspect = $sNamespace;
			}

			return $sAspect;
		}

		/**
		 * Extract the name of a DOMNode
		 *
		 * @param DOMNode $objDOMNode the source DOMNode
		 * @return string the name of the node
		 */
		private function extractName(DOMNode $objDOMNode) {

			return $objDOMNode -> localName;
		}

		/**
		 * Returns the text of a DOMNode which is the sum of all #TEXT nodes of
		 * a given DOMNode.
		 *
		 * @param DOMNode $objDOMNode the source DOMNode
		 * @return string all concatenated text nodes (finally trimmed)
		 */
		private function extractValue(DOMNode $objDOMNode) {

			$sValue = '';
			foreach ($objDOMNode -> childNodes as $objDOMChildNode) {

				if ($objDOMChildNode -> nodeType == XML_TEXT_NODE) {

					$sValue .= $objDOMChildNode -> nodeValue;
				}
			}

			return trim($sValue);
		}

		/**
		 * Return true, if the given element is a child element of the document's root element.
		 *
		 * @param DOMElement $objDOMElement the DOMElement to check
		 * @return boolean return true if the parent node of the parent node is given and of type XML_DOCUMENT_NODE
		 */
		private function isChildElementOfRootElement(DOMElement $objDOMElement) {

			if ($objDOMElement -> parentNode === NULL) {

				return false;
			}

			if ($objDOMElement -> parentNode -> parentNode === NULL) {

				return false;
			}

			if ($objDOMElement -> parentNode -> parentNode -> nodeType == XML_DOCUMENT_NODE) {

				return true;
			}

			return false;
		}

		/**
		 * Returns the list of all processed files (including all included files)
		 *
		 * @return array of file names
		 */
		public function getProcessedFiles() {

			return self :: $arrLoadedFiles;
		}
	}
